// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * Scanner of the entries.
 * @constructor
 */
function ContentScanner() {
  this.cancelled_ = false;
}

/**
 * Starts to scan the entries. For example, starts to read the entries in a
 * directory, or starts to search with some query on a file system.
 * Derived classes must override this method.
 *
 * @param {function(Array<Entry>)} entriesCallback Called when some chunk of
 *     entries are read. This can be called a couple of times until the
 *     completion.
 * @param {function()} successCallback Called when the scan is completed
 *     successfully.
 * @param {function(DOMError)} errorCallback Called an error occurs.
 */
ContentScanner.prototype.scan = function(
    entriesCallback, successCallback, errorCallback) {
};

/**
 * Request cancelling of the running scan. When the cancelling is done,
 * an error will be reported from errorCallback passed to scan().
 */
ContentScanner.prototype.cancel = function() {
  this.cancelled_ = true;
};

/**
 * Scanner of the entries in a directory.
 * @param {DirectoryEntry} entry The directory to be read.
 * @constructor
 * @extends {ContentScanner}
 */
function DirectoryContentScanner(entry) {
  ContentScanner.call(this);
  this.entry_ = entry;
}

/**
 * Extends ContentScanner.
 */
DirectoryContentScanner.prototype.__proto__ = ContentScanner.prototype;

/**
 * Starts to read the entries in the directory.
 * @override
 */
DirectoryContentScanner.prototype.scan = function(
    entriesCallback, successCallback, errorCallback) {
  if (!this.entry_ || util.isFakeEntry(this.entry_)) {
    // If entry is not specified or a fake, we cannot read it.
    errorCallback(util.createDOMError(
        util.FileError.INVALID_MODIFICATION_ERR));
    return;
  }

  metrics.startInterval('DirectoryScan');
  var reader = this.entry_.createReader();
  var readEntries = function() {
    reader.readEntries(
        function(entries) {
          if (this.cancelled_) {
            errorCallback(util.createDOMError(util.FileError.ABORT_ERR));
            return;
          }

          if (entries.length === 0) {
            // All entries are read.
            metrics.recordInterval('DirectoryScan');
            successCallback();
            return;
          }

          entriesCallback(entries);
          readEntries();
        }.bind(this),
        errorCallback);
  }.bind(this);
  readEntries();
};

/**
 * Scanner of the entries for the search results on Drive File System.
 * @param {string} query The query string.
 * @constructor
 * @extends {ContentScanner}
 */
function DriveSearchContentScanner(query) {
  ContentScanner.call(this);
  this.query_ = query;
}

/**
 * Extends ContentScanner.
 */
DriveSearchContentScanner.prototype.__proto__ = ContentScanner.prototype;

/**
 * Delay in milliseconds to be used for drive search scan, in order to reduce
 * the number of server requests while user is typing the query.
 * @type {number}
 * @private
 * @const
 */
DriveSearchContentScanner.SCAN_DELAY_ = 200;

/**
 * Maximum number of results which is shown on the search.
 * @type {number}
 * @private
 * @const
 */
DriveSearchContentScanner.MAX_RESULTS_ = 100;

/**
 * Starts to search on Drive File System.
 * @override
 */
DriveSearchContentScanner.prototype.scan = function(
    entriesCallback, successCallback, errorCallback) {
  var numReadEntries = 0;
  var readEntries = function(nextFeed) {
    chrome.fileManagerPrivate.searchDrive(
        {query: this.query_, nextFeed: nextFeed},
        function(entries, nextFeed) {
          if (this.cancelled_) {
            errorCallback(util.createDOMError(util.FileError.ABORT_ERR));
            return;
          }

          // TODO(tbarzic): Improve error handling.
          if (!entries) {
            console.error('Drive search encountered an error.');
            errorCallback(util.createDOMError(
                util.FileError.INVALID_MODIFICATION_ERR));
            return;
          }

          var numRemainingEntries =
              DriveSearchContentScanner.MAX_RESULTS_ - numReadEntries;
          if (entries.length >= numRemainingEntries) {
            // The limit is hit, so quit the scan here.
            entries = entries.slice(0, numRemainingEntries);
            nextFeed = '';
          }

          numReadEntries += entries.length;
          if (entries.length > 0)
            entriesCallback(entries);

          if (nextFeed === '')
            successCallback();
          else
            readEntries(nextFeed);
        }.bind(this));
  }.bind(this);

  // Let's give another search a chance to cancel us before we begin.
  setTimeout(
      function() {
        // Check cancelled state before read the entries.
        if (this.cancelled_) {
          errorCallback(util.createDOMError(util.FileError.ABORT_ERR));
          return;
        }
        readEntries('');
      }.bind(this),
      DriveSearchContentScanner.SCAN_DELAY_);
};

/**
 * Scanner of the entries of the file name search on the directory tree, whose
 * root is entry.
 * @param {DirectoryEntry} entry The root of the search target directory tree.
 * @param {string} query The query of the search.
 * @constructor
 * @extends {ContentScanner}
 */
function LocalSearchContentScanner(entry, query) {
  ContentScanner.call(this);
  this.entry_ = entry;
  this.query_ = query.toLowerCase();
}

/**
 * Extends ContentScanner.
 */
LocalSearchContentScanner.prototype.__proto__ = ContentScanner.prototype;

/**
 * Starts the file name search.
 * @override
 */
LocalSearchContentScanner.prototype.scan = function(
    entriesCallback, successCallback, errorCallback) {
  var numRunningTasks = 0;
  var error = null;
  var maybeRunCallback = function() {
    if (numRunningTasks === 0) {
      if (this.cancelled_)
        errorCallback(util.createDOMError(util.FileError.ABORT_ERR));
      else if (error)
        errorCallback(error);
      else
        successCallback();
    }
  }.bind(this);

  var processEntry = function(entry) {
    numRunningTasks++;
    var onError = function(fileError) {
      if (!error)
        error = fileError;
      numRunningTasks--;
      maybeRunCallback();
    };

    var onSuccess = function(entries) {
      if (this.cancelled_ || error || entries.length === 0) {
        numRunningTasks--;
        maybeRunCallback();
        return;
      }

      // Filters by the query, and if found, run entriesCallback.
      var foundEntries = entries.filter(function(entry) {
        return entry.name.toLowerCase().indexOf(this.query_) >= 0;
      }.bind(this));
      if (foundEntries.length > 0)
        entriesCallback(foundEntries);

      // Start to process sub directories.
      for (var i = 0; i < entries.length; i++) {
        if (entries[i].isDirectory)
          processEntry(entries[i]);
      }

      // Read remaining entries.
      reader.readEntries(onSuccess, onError);
    }.bind(this);

    var reader = entry.createReader();
    reader.readEntries(onSuccess, onError);
  }.bind(this);

  processEntry(this.entry_);
};

/**
 * Scanner of the entries for the metadata search on Drive File System.
 * @param {!DriveMetadataSearchContentScanner.SearchType} searchType The option
 *     of the search.
 * @constructor
 * @extends {ContentScanner}
 */
function DriveMetadataSearchContentScanner(searchType) {
  ContentScanner.call(this);
  this.searchType_ = searchType;
}

/**
 * Extends ContentScanner.
 */
DriveMetadataSearchContentScanner.prototype.__proto__ =
    ContentScanner.prototype;

/**
 * The search types on the Drive File System.
 * @enum {string}
 */
DriveMetadataSearchContentScanner.SearchType = {
  SEARCH_ALL: 'ALL',
  SEARCH_SHARED_WITH_ME: 'SHARED_WITH_ME',
  SEARCH_RECENT_FILES: 'EXCLUDE_DIRECTORIES',
  SEARCH_OFFLINE: 'OFFLINE'
};
Object.freeze(DriveMetadataSearchContentScanner.SearchType);

/**
 * Starts to metadata-search on Drive File System.
 * @override
 */
DriveMetadataSearchContentScanner.prototype.scan = function(
    entriesCallback, successCallback, errorCallback) {
  chrome.fileManagerPrivate.searchDriveMetadata(
      {query: '', types: this.searchType_, maxResults: 500},
      function(results) {
        if (this.cancelled_) {
          errorCallback(util.createDOMError(util.FileError.ABORT_ERR));
          return;
        }

        if (!results) {
          console.error('Drive search encountered an error.');
          errorCallback(util.createDOMError(
              util.FileError.INVALID_MODIFICATION_ERR));
          return;
        }

        var entries = results.map(function(result) { return result.entry; });
        if (entries.length > 0)
          entriesCallback(entries);
        successCallback();
      }.bind(this));
};

/**
 * This class manages filters and determines a file should be shown or not.
 * When filters are changed, a 'changed' event is fired.
 *
 * @param {boolean} showHidden If files starting with '.' or ending with
 *     '.crdownlaod' are shown.
 * @constructor
 * @extends {cr.EventTarget}
 */
function FileFilter(showHidden) {
  /**
   * @type {Object<Function>}
   * @private
   */
  this.filters_ = {};
  this.setFilterHidden(!showHidden);
}

/*
 * FileFilter extends cr.EventTarget.
 */
FileFilter.prototype = {__proto__: cr.EventTarget.prototype};

/**
 * @param {string} name Filter identifier.
 * @param {function(Entry)} callback A filter — a function receiving an Entry,
 *     and returning bool.
 */
FileFilter.prototype.addFilter = function(name, callback) {
  this.filters_[name] = callback;
  cr.dispatchSimpleEvent(this, 'changed');
};

/**
 * @param {string} name Filter identifier.
 */
FileFilter.prototype.removeFilter = function(name) {
  delete this.filters_[name];
  cr.dispatchSimpleEvent(this, 'changed');
};

/**
 * @param {boolean} value If do not show hidden files.
 */
FileFilter.prototype.setFilterHidden = function(value) {
  var regexpCrdownloadExtension = /\.crdownload$/i;
  if (value) {
    this.addFilter(
        'hidden',
        function(entry) {
          return entry.name.substr(0, 1) !== '.' &&
                 !regexpCrdownloadExtension.test(entry.name);
        }
    );
  } else {
    this.removeFilter('hidden');
  }
};

/**
 * @return {boolean} If the files with names starting with "." are not shown.
 */
FileFilter.prototype.isFilterHiddenOn = function() {
  return 'hidden' in this.filters_;
};

/**
 * @param {Entry} entry File entry.
 * @return {boolean} True if the file should be shown, false otherwise.
 */
FileFilter.prototype.filter = function(entry) {
  for (var name in this.filters_) {
    if (!this.filters_[name](entry))
      return false;
  }
  return true;
};

/**
 * File list.
 * @param {!MetadataModel} metadataModel
 * @constructor
 * @extends {cr.ui.ArrayDataModel}
 */
function FileListModel(metadataModel) {
  cr.ui.ArrayDataModel.call(this, []);

  /**
   * @private {!MetadataModel}
   * @const
   */
  this.metadataModel_ = metadataModel;

  // Initialize compare functions.
  this.setCompareFunction('name',
      /** @type {function(*, *): number} */ (this.compareName_.bind(this)));
  this.setCompareFunction('modificationTime',
      /** @type {function(*, *): number} */ (this.compareMtime_.bind(this)));
  this.setCompareFunction('size',
      /** @type {function(*, *): number} */ (this.compareSize_.bind(this)));
  this.setCompareFunction('type',
      /** @type {function(*, *): number} */ (this.compareType_.bind(this)));

  /**
   * Whether this file list is sorted in descending order.
   * @type {boolean}
   * @private
   */
  this.isDescendingOrder_ = false;

  /**
   * The number of folders in the list.
   * @private {number}
   */
  this.numFolders_ = 0;

  /**
   * The number of files in the list.
   * @private {number}
   */
  this.numFiles_ = 0;

  /**
   * The number of image files in the list.
   * @private {number}
   */
  this.numImageFiles_ = 0;
}

/**
 * @param {!Object} fileType Type object returned by FileType.getType().
 * @return {string} Localized string representation of file type.
 */
FileListModel.getFileTypeString = function(fileType) {
  if (fileType.subtype)
    return strf(fileType.name, fileType.subtype);
  else
    return str(fileType.name);
};

FileListModel.prototype = {
  __proto__: cr.ui.ArrayDataModel.prototype
};

/**
 * Sorts data model according to given field and direction and dispathes
 * sorted event.
 * @param {string} field Sort field.
 * @param {string} direction Sort direction.
 * @override
 */
FileListModel.prototype.sort = function(field, direction) {
  this.isDescendingOrder_ = direction === 'desc';
  cr.ui.ArrayDataModel.prototype.sort.call(this, field, direction);
};

/**
 * Called before a sort happens so that you may fetch additional data
 * required for the sort.
 * @param {string} field Sort field.
 * @param {function()} callback The function to invoke when preparation
 *     is complete.
 * @override
 */
FileListModel.prototype.prepareSort = function(field, callback) {
  // Starts the actual sorting immediately as we don't need any preparation to
  // sort the file list and we want to start actual sorting as soon as possible
  // after we get the |this.isDescendingOrder_| value in sort().
  callback();
};

/**
 * Removes and adds items to the model.
 * @param {number} index The index of the item to update.
 * @param {number} deleteCount The number of items to remove.
 * @param {...*} var_args The items to add.
 * @return {!Array} An array with the removed items.
 * @override
 */
FileListModel.prototype.splice = function(index, deleteCount, var_args) {
  for (var i = index; i < index + deleteCount; i++) {
    if (i >= 0 && i < this.length)
      this.onRemoveEntryFromList_(/** @type {!Entry} */ (this.item(i)));
  }
  for (var i = 2; i < arguments.length; i++) {
    this.onAddEntryToList_(arguments[i]);
  }

  return cr.ui.ArrayDataModel.prototype.splice.apply(this, arguments);
};

/**
 * @override
 */
FileListModel.prototype.replaceItem = function(oldItem, newItem) {
  this.onRemoveEntryFromList_(oldItem);
  this.onAddEntryToList_(newItem);

  cr.ui.ArrayDataModel.prototype.replaceItem.apply(this, arguments);
};

/**
 * Returns the number of files in this file list.
 * @return {number} The number of files.
 */
FileListModel.prototype.getFileCount = function() {
  return this.numFiles_;
};

/**
 * Returns the number of folders in this file list.
 * @return {number} The number of folders.
 */
FileListModel.prototype.getFolderCount = function() {
  return this.numFolders_;
};

/**
 * Returns true if image files are dominant in this file list.
 * @return {boolean}
 */
FileListModel.prototype.isImageDominant = function() {
  return this.numFiles_ >= 0 &&
      this.numImageFiles_ / this.numFiles_ >= 0.8;
};

/**
 * Updates the statistics about contents when new entry is about to be added.
 * @param {Entry} entry Entry of the new item.
 * @private
 */
FileListModel.prototype.onAddEntryToList_ = function(entry) {
  if (entry.isDirectory)
    this.numFolders_++;
  else
    this.numFiles_++;

  var mimeType = this.metadataModel_.getCache([entry],
      ['contentMimeType'])[0].contentMimeType;
  if (FileType.isImage(entry, mimeType) || FileType.isRaw(entry, mimeType))
    this.numImageFiles_++;
};

/**
 * Updates the statistics about contents when an entry is about to be removed.
 * @param {Entry} entry Entry of the item to be removed.
 * @private
 */
FileListModel.prototype.onRemoveEntryFromList_ = function(entry) {
  if (entry.isDirectory)
    this.numFolders_--;
  else
    this.numFiles_--;

  var mimeType = this.metadataModel_.getCache([entry],
      ['contentMimeType'])[0].contentMimeType;
  if (FileType.isImage(entry, mimeType) || FileType.isRaw(entry, mimeType))
    this.numImageFiles_--;
};

/**
 * Compares entries by name.
 * @param {!Entry} a First entry.
 * @param {!Entry} b Second entry.
 * @return {number} Compare result.
 * @private
 */
FileListModel.prototype.compareName_ = function(a, b) {
  // Directories always precede files.
  if (a.isDirectory !== b.isDirectory)
    return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;

  return util.compareName(a, b);
};

/**
 * Compares entries by mtime first, then by name.
 * @param {Entry} a First entry.
 * @param {Entry} b Second entry.
 * @return {number} Compare result.
 * @private
 */
FileListModel.prototype.compareMtime_ = function(a, b) {
  // Directories always precede files.
  if (a.isDirectory !== b.isDirectory)
    return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;

  var properties =
      this.metadataModel_.getCache([a, b], ['modificationTime']);
  var aTime = properties[0].modificationTime || 0;
  var bTime = properties[1].modificationTime || 0;

  if (aTime > bTime)
    return 1;

  if (aTime < bTime)
    return -1;

  return util.compareName(a, b);
};

/**
 * Compares entries by size first, then by name.
 * @param {Entry} a First entry.
 * @param {Entry} b Second entry.
 * @return {number} Compare result.
 * @private
 */
FileListModel.prototype.compareSize_ = function(a, b) {
  // Directories always precede files.
  if (a.isDirectory !== b.isDirectory)
    return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;

  var properties = this.metadataModel_.getCache([a, b], ['size']);
  var aSize = properties[0].size || 0;
  var bSize = properties[1].size || 0;

  return aSize !== bSize ? aSize - bSize : util.compareName(a, b);
};

/**
 * Compares entries by type first, then by subtype and then by name.
 * @param {Entry} a First entry.
 * @param {Entry} b Second entry.
 * @return {number} Compare result.
 * @private
 */
FileListModel.prototype.compareType_ = function(a, b) {
  // Directories always precede files.
  if (a.isDirectory !== b.isDirectory)
    return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;

  var properties = this.metadataModel_.getCache([a, b], ['contentMimeType']);
  var aType = FileListModel.getFileTypeString(
      FileType.getType(a, properties[0].contentMimeType));
  var bType = FileListModel.getFileTypeString(
      FileType.getType(b, properties[1].contentMimeType));

  var result = util.collator.compare(aType, bType);
  return result !== 0 ? result : util.compareName(a, b);
};

/**
 * A context of DirectoryContents.
 * TODO(yoshiki): remove this. crbug.com/224869.
 *
 * @param {FileFilter} fileFilter The file-filter context.
 * @param {!MetadataModel} metadataModel
 * @constructor
 */
function FileListContext(fileFilter, metadataModel) {
  /**
   * @type {FileListModel}
   */
  this.fileList = new FileListModel(metadataModel);

  /**
   * @public {!MetadataModel}
   * @const
   */
  this.metadataModel = metadataModel;

  /**
   * @type {FileFilter}
   */
  this.fileFilter = fileFilter;

  /**
   * @public {!Array<string>}
   * @const
   */
  this.prefetchPropertyNames = FileListContext.createPrefetchPropertyNames_();
}

/**
 * @return {!Array<string>}
 * @private
 */
FileListContext.createPrefetchPropertyNames_ = function() {
  var set = {};
  for (var i = 0;
       i < ListContainer.METADATA_PREFETCH_PROPERTY_NAMES.length;
       i++) {
    set[ListContainer.METADATA_PREFETCH_PROPERTY_NAMES[i]] = true;
  }
  for (var i = 0; i < ActionsModel.METADATA_PREFETCH_PROPERTY_NAMES.length;
       i++) {
    set[ActionsModel.METADATA_PREFETCH_PROPERTY_NAMES[i]] = true;
  }
  for (var i = 0;
       i < FileSelection.METADATA_PREFETCH_PROPERTY_NAMES.length;
       i++) {
    set[FileSelection.METADATA_PREFETCH_PROPERTY_NAMES[i]] = true;
  }
  return Object.keys(set);
};

/**
 * This class is responsible for scanning directory (or search results),
 * and filling the fileList. Different descendants handle various types of
 * directory contents shown: basic directory, drive search results, local search
 * results.
 * TODO(hidehiko): Remove EventTarget from this.
 *
 * @param {FileListContext} context The file list context.
 * @param {boolean} isSearch True for search directory contents, otherwise
 *     false.
 * @param {DirectoryEntry|FakeEntry} directoryEntry The entry of the current
 *     directory.
 * @param {function():ContentScanner} scannerFactory The factory to create
 *     ContentScanner instance.
 * @constructor
 * @extends {cr.EventTarget}
 */
function DirectoryContents(context,
                           isSearch,
                           directoryEntry,
                           scannerFactory) {
  this.context_ = context;
  this.fileList_ = context.fileList;

  this.isSearch_ = isSearch;
  this.directoryEntry_ = directoryEntry;

  this.scannerFactory_ = scannerFactory;
  this.scanner_ = null;
  this.processNewEntriesQueue_ = new AsyncUtil.Queue();
  this.scanCancelled_ = false;

  /**
   * Metadata snapshot which is used to know which file is actually changed.
   * @type {Object}
   */
  this.metadataSnapshot_ = null;
}

/**
 * DirectoryContents extends cr.EventTarget.
 */
DirectoryContents.prototype.__proto__ = cr.EventTarget.prototype;

/**
 * Create the copy of the object, but without scan started.
 * @return {!DirectoryContents} Object copy.
 */
DirectoryContents.prototype.clone = function() {
  return new DirectoryContents(
      this.context_,
      this.isSearch_,
      this.directoryEntry_,
      this.scannerFactory_);
};

/**
 * Use a given fileList instead of the fileList from the context.
 * @param {(!Array|!cr.ui.ArrayDataModel)} fileList The new file list.
 */
DirectoryContents.prototype.setFileList = function(fileList) {
  if (fileList instanceof cr.ui.ArrayDataModel)
    this.fileList_ = fileList;
  else
    this.fileList_ = new cr.ui.ArrayDataModel(fileList);
};

/**
 * Creates snapshot of metadata in the directory.
 * @return {!Object} Metadata snapshot of current directory contents.
 */
DirectoryContents.prototype.createMetadataSnapshot = function() {
  var snapshot = {};
  var entries = /** @type {!Array<!Entry>} */ (this.fileList_.slice());
  var metadata = this.context_.metadataModel.getCache(
      entries, ['modificationTime']);
  for (var i = 0; i < entries.length; i++) {
    snapshot[entries[i].toURL()] = metadata[i];
  }
  return snapshot;
}

/**
 * Sets metadata snapshot which is used to check changed files.
 * @param {!Object} metadataSnapshot A metadata snapshot.
 */
DirectoryContents.prototype.setMetadataSnapshot = function(metadataSnapshot) {
  this.metadataSnapshot_ = metadataSnapshot;
}

/**
 * Use the filelist from the context and replace its contents with the entries
 * from the current fileList. If metadata snapshot is set, this method checks
 * actually updated files and dispatch change events by calling updateIndexes.
 */
DirectoryContents.prototype.replaceContextFileList = function() {
  if (this.context_.fileList !== this.fileList_) {
    // TODO(yawano): While we should update the list with adding or deleting
    // what actually added and deleted instead of deleting and adding all items,
    // splice of array data model is expensive since it always runs sort and we
    // replace the list in this way to reduce the number of splice calls.
    var spliceArgs = this.fileList_.slice();
    var fileList = this.context_.fileList;
    spliceArgs.unshift(0, fileList.length);
    fileList.splice.apply(fileList, spliceArgs);
    this.fileList_ = fileList;

    // Check updated files and dispatch change events.
    if (this.metadataSnapshot_) {
      var updatedIndexes = [];
      var entries = /** @type {!Array<!Entry>} */ (this.fileList_.slice());
      var newMetadatas = this.context_.metadataModel.getCache(
          entries, ['modificationTime']);

      for (var i = 0; i < entries.length; i++) {
        var url = entries[i].toURL();
        var newMetadata = newMetadatas[i];
        // If Files.app fails to obtain both old and new modificationTime,
        // regard the entry as not updated.
        if ((this.metadataSnapshot_[url] &&
             this.metadataSnapshot_[url].modificationTime &&
             this.metadataSnapshot_[url].modificationTime.getTime()) !==
            (newMetadata.modificationTime &&
             newMetadata.modificationTime.getTime())) {
          updatedIndexes.push(i);
        }
      }

      if (updatedIndexes.length > 0)
        this.fileList_.updateIndexes(updatedIndexes);
    }
  }
};

/**
 * @return {boolean} If the scan is active.
 */
DirectoryContents.prototype.isScanning = function() {
  return this.scanner_ || this.processNewEntriesQueue_.isRunning();
};

/**
 * @return {boolean} True if search results (drive or local).
 */
DirectoryContents.prototype.isSearch = function() {
  return this.isSearch_;
};

/**
 * @return {DirectoryEntry|FakeEntry} A DirectoryEntry for current directory.
 *     In case of search -- the top directory from which search is run.
 */
DirectoryContents.prototype.getDirectoryEntry = function() {
  return this.directoryEntry_;
};

/**
 * Start directory scan/search operation. Either 'scan-completed' or
 * 'scan-failed' event will be fired upon completion.
 *
 * @param {boolean} refresh True to refresh metadata, or false to use cached
 *     one.
 */
DirectoryContents.prototype.scan = function(refresh) {
  /**
   * Invoked when the scanning is completed successfully.
   * @this {DirectoryContents}
   */
  function completionCallback() {
    this.onScanFinished_();
    this.onScanCompleted_();
  }

  /**
   * Invoked when the scanning is finished but is not completed due to error.
   * @this {DirectoryContents}
   */
  function errorCallback() {
    this.onScanFinished_();
    this.onScanError_();
  }

  // TODO(hidehiko,mtomasz): this scan method must be called at most once.
  // Remove such a limitation.
  this.scanner_ = this.scannerFactory_();
  this.scanner_.scan(this.onNewEntries_.bind(this, refresh),
                     completionCallback.bind(this),
                     errorCallback.bind(this));
};

/**
 * Adds/removes/updates items of file list.
 * @param {Array<Entry>} updatedEntries Entries of updated/added files.
 * @param {Array<string>} removedUrls URLs of removed files.
 */
DirectoryContents.prototype.update = function(updatedEntries, removedUrls) {
  var removedMap = {};
  for (var i = 0; i < removedUrls.length; i++) {
    removedMap[removedUrls[i]] = true;
  }

  var updatedMap = {};
  for (var i = 0; i < updatedEntries.length; i++) {
    updatedMap[updatedEntries[i].toURL()] = updatedEntries[i];
  }

  var updatedList = [];
  var updatedIndexes = [];
  for (var i = 0; i < this.fileList_.length; i++) {
    var url = this.fileList_.item(i).toURL();

    if (url in removedMap) {
      // Find the maximum range in which all items need to be removed.
      var begin = i;
      var end = i + 1;
      while (end < this.fileList_.length &&
             this.fileList_.item(end).toURL() in removedMap) {
        end++;
      }
      // Remove the range [begin, end) at once to avoid multiple sorting.
      this.fileList_.splice(begin, end - begin);
      i--;
      continue;
    }

    if (url in updatedMap) {
      updatedList.push(updatedMap[url]);
      updatedIndexes.push(i);
      delete updatedMap[url];
    }
  }

  if (updatedIndexes.length > 0)
    this.fileList_.updateIndexes(updatedIndexes);

  var addedList = [];
  for (var url in updatedMap) {
    addedList.push(updatedMap[url]);
  }

  if (removedUrls.length > 0)
    this.context_.metadataModel.notifyEntriesRemoved(removedUrls);

  this.prefetchMetadata(updatedList, true, function() {
    this.onNewEntries_(true, addedList);
    this.onScanFinished_();
    this.onScanCompleted_();
  }.bind(this));
};

/**
 * Cancels the running scan.
 */
DirectoryContents.prototype.cancelScan = function() {
  if (this.scanCancelled_)
    return;
  this.scanCancelled_ = true;
  if (this.scanner_)
    this.scanner_.cancel();

  this.onScanFinished_();

  this.processNewEntriesQueue_.cancel();
  cr.dispatchSimpleEvent(this, 'scan-cancelled');
};

/**
 * Called when the scanning by scanner_ is done, even when the scanning is
 * succeeded or failed. This is called before completion (or error) callback.
 *
 * @private
 */
DirectoryContents.prototype.onScanFinished_ = function() {
  this.scanner_ = null;
};

/**
 * Called when the scanning by scanner_ is succeeded.
 * @private
 */
DirectoryContents.prototype.onScanCompleted_ = function() {
  if (this.scanCancelled_)
    return;

  this.processNewEntriesQueue_.run(function(callback) {
    // Call callback first, so isScanning() returns false in the event handlers.
    callback();

    cr.dispatchSimpleEvent(this, 'scan-completed');
  }.bind(this));
};

/**
 * Called in case scan has failed. Should send the event.
 * @private
 */
DirectoryContents.prototype.onScanError_ = function() {
  if (this.scanCancelled_)
    return;

  this.processNewEntriesQueue_.run(function(callback) {
    // Call callback first, so isScanning() returns false in the event handlers.
    callback();
    cr.dispatchSimpleEvent(this, 'scan-failed');
  }.bind(this));
};

/**
 * Called when some chunk of entries are read by scanner.
 *
 * @param {boolean} refresh True to refresh metadata, or false to use cached
 *     one.
 * @param {Array<Entry>} entries The list of the scanned entries.
 * @private
 */
DirectoryContents.prototype.onNewEntries_ = function(refresh, entries) {
  if (this.scanCancelled_)
    return;

  var entriesFiltered = [].filter.call(
      entries, this.context_.fileFilter.filter.bind(this.context_.fileFilter));

  // Caching URL to reduce a number of calls of toURL in sort.
  // This is a temporary solution. We need to fix a root cause of slow toURL.
  // See crbug.com/370908 for detail.
  entriesFiltered.forEach(function(entry) {
    entry['cachedUrl'] = entry.toURL();
  });

  if (entriesFiltered.length === 0)
    return;

  // Enlarge the cache size into the new filelist size.
  var newListSize = this.fileList_.length + entriesFiltered.length;

  this.processNewEntriesQueue_.run(function(callbackOuter) {
    var finish = function() {
      if (!this.scanCancelled_) {
        // Just before inserting entries into the file list, check and avoid
        // duplication.
        var currentURLs = {};
        for (var i = 0; i < this.fileList_.length; i++)
          currentURLs[this.fileList_.item(i).toURL()] = true;
        entriesFiltered = entriesFiltered.filter(function(entry) {
          return !currentURLs[entry.toURL()];
        });
        // Update the filelist without waiting the metadata.
        this.fileList_.push.apply(this.fileList_, entriesFiltered);
        cr.dispatchSimpleEvent(this, 'scan-updated');
      }
      callbackOuter();
    }.bind(this);
    // Because the prefetchMetadata can be slow, throttling by splitting entries
    // into smaller chunks to reduce UI latency.
    // TODO(hidehiko,mtomasz): This should be handled in MetadataCache.
    var MAX_CHUNK_SIZE = 25;
    var prefetchMetadataQueue = new AsyncUtil.ConcurrentQueue(4);
    for (var i = 0; i < entriesFiltered.length; i += MAX_CHUNK_SIZE) {
      if (prefetchMetadataQueue.isCancelled())
        break;

      var chunk = entriesFiltered.slice(i, i + MAX_CHUNK_SIZE);
      prefetchMetadataQueue.run(function(chunk, callbackInner) {
        this.prefetchMetadata(chunk, refresh, function() {
          if (!prefetchMetadataQueue.isCancelled()) {
            if (this.scanCancelled_)
              prefetchMetadataQueue.cancel();
          }

          // Checks if this is the last task.
          if (prefetchMetadataQueue.getWaitingTasksCount() === 0 &&
              prefetchMetadataQueue.getRunningTasksCount() === 1) {
            // |callbackOuter| in |finish| must be called before
            // |callbackInner|, to prevent double-calling.
            finish();
          }

          callbackInner();
        }.bind(this));
      }.bind(this, chunk));
    }
  }.bind(this));
};

/**
 * @param {!Array<!Entry>} entries Files.
 * @param {boolean} refresh True to refresh metadata, or false to use cached
 *     one.
 * @param {function(Object)} callback Callback on done.
 */
DirectoryContents.prototype.prefetchMetadata =
    function(entries, refresh, callback) {
  if (refresh)
    this.context_.metadataModel.notifyEntriesChanged(entries);
  this.context_.metadataModel.get(
      entries, this.context_.prefetchPropertyNames).then(callback);
};

/**
 * Creates a DirectoryContents instance to show entries in a directory.
 *
 * @param {FileListContext} context File list context.
 * @param {DirectoryEntry} directoryEntry The current directory entry.
 * @return {DirectoryContents} Created DirectoryContents instance.
 */
DirectoryContents.createForDirectory = function(context, directoryEntry) {
  return new DirectoryContents(
      context,
      false,  // Non search.
      directoryEntry,
      function() {
        return new DirectoryContentScanner(directoryEntry);
      });
};

/**
 * Creates a DirectoryContents instance to show the result of the search on
 * Drive File System.
 *
 * @param {FileListContext} context File list context.
 * @param {DirectoryEntry} directoryEntry The current directory entry.
 * @param {string} query Search query.
 * @return {DirectoryContents} Created DirectoryContents instance.
 */
DirectoryContents.createForDriveSearch = function(
    context, directoryEntry, query) {
  return new DirectoryContents(
      context,
      true,  // Search.
      directoryEntry,
      function() {
        return new DriveSearchContentScanner(query);
      });
};

/**
 * Creates a DirectoryContents instance to show the result of the search on
 * Local File System.
 *
 * @param {FileListContext} context File list context.
 * @param {DirectoryEntry} directoryEntry The current directory entry.
 * @param {string} query Search query.
 * @return {DirectoryContents} Created DirectoryContents instance.
 */
DirectoryContents.createForLocalSearch = function(
    context, directoryEntry, query) {
  return new DirectoryContents(
      context,
      true,  // Search.
      directoryEntry,
      function() {
        return new LocalSearchContentScanner(directoryEntry, query);
      });
};

/**
 * Creates a DirectoryContents instance to show the result of metadata search
 * on Drive File System.
 *
 * @param {FileListContext} context File list context.
 * @param {!FakeEntry} fakeDirectoryEntry Fake directory entry representing the
 *     set of result entries. This serves as a top directory for the search.
 * @param {!DriveMetadataSearchContentScanner.SearchType} searchType The type of
 *     the search. The scanner will restricts the entries based on the given
 *     type.
 * @return {DirectoryContents} Created DirectoryContents instance.
 */
DirectoryContents.createForDriveMetadataSearch = function(
    context, fakeDirectoryEntry, searchType) {
  return new DirectoryContents(
      context,
      true,  // Search
      fakeDirectoryEntry,
      function() {
        return new DriveMetadataSearchContentScanner(searchType);
      });
};
